/** * GET /_emdash/api/auth/oauth/[provider] * * Start OAuth flow - redirects to provider authorization URL */ import type { APIRoute } from "astro"; export const prerender = false; import { createAuthorizationUrl, type OAuthConsumerConfig } from "@premium-cms/auth"; import { getPublicOrigin } from "#api/public-url.js"; import { createOAuthStateStore } from "#auth/oauth-state-store.js"; type ProviderName = "github" | "google"; const VALID_PROVIDERS = new Set(["github", "google"]); /** Invite tokens are base64url; clamp shape and length before persisting to state. */ const INVITE_TOKEN_REGEX = /^[A-Za-z0-9_-]{1,256}$/; function isValidProvider(provider: string): provider is ProviderName { return VALID_PROVIDERS.has(provider); } /** Safely extract a string value from an env-like record */ function envString(env: Record, ...keys: string[]): string | undefined { for (const key of keys) { const val = env[key]; if (typeof val === "string" && val) return val; } return undefined; } /** * Get OAuth config from environment variables */ function getOAuthConfig(env: Record): OAuthConsumerConfig["providers"] { const providers: OAuthConsumerConfig["providers"] = {}; // GitHub const githubClientId = envString(env, "EMDASH_OAUTH_GITHUB_CLIENT_ID", "GITHUB_CLIENT_ID"); const githubClientSecret = envString( env, "EMDASH_OAUTH_GITHUB_CLIENT_SECRET", "GITHUB_CLIENT_SECRET", ); if (githubClientId && githubClientSecret) { providers.github = { clientId: githubClientId, clientSecret: githubClientSecret, }; } // Google const googleClientId = envString(env, "EMDASH_OAUTH_GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_ID"); const googleClientSecret = envString( env, "EMDASH_OAUTH_GOOGLE_CLIENT_SECRET", "GOOGLE_CLIENT_SECRET", ); if (googleClientId && googleClientSecret) { providers.google = { clientId: googleClientId, clientSecret: googleClientSecret, }; } return providers; } export const GET: APIRoute = async ({ params, request, locals, redirect }) => { const { emdash } = locals; const provider = params.provider; // Determine where to redirect errors (setup wizard or login page) const referer = request.headers.get("referer") ?? ""; const errorRedirectBase = referer.includes("/setup") ? "/_emdash/admin/setup" : "/_emdash/admin/login"; // Validate provider if (!provider || !isValidProvider(provider)) { return redirect( `${errorRedirectBase}?error=invalid_provider&message=${encodeURIComponent("Invalid OAuth provider")}`, ); } if (!emdash?.db) { return redirect( `${errorRedirectBase}?error=server_error&message=${encodeURIComponent("Database not configured")}`, ); } try { const url = new URL(request.url); // Get OAuth providers from environment. Astro 6 removed // `Astro.locals.runtime.env` (accessing it throws rather than // returning undefined, so optional-chaining doesn't help) -- read // Cloudflare bindings via the emdash virtual module instead, which // re-exports `cloudflare:workers`' `env` under that adapter and // falls back to `import.meta.env` on Node (#1736). // @ts-ignore - virtual module, generated by the Astro integration const { env: cfEnv } = (await import("virtual:emdash/env")) as { env?: Record; }; const env = cfEnv ?? import.meta.env; const providers = getOAuthConfig(env); if (!providers[provider]) { return redirect( `${errorRedirectBase}?error=provider_not_configured&message=${encodeURIComponent(`OAuth provider ${provider} is not configured. Set either EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_ID and EMDASH_OAUTH_${provider.toUpperCase()}_CLIENT_SECRET, or ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET.`)}`, ); } const config: OAuthConsumerConfig = { baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`, providers, }; const stateStore = createOAuthStateStore(emdash.db); // When the flow starts from an invite link, carry the invite token so the // callback can complete the invite for a matching, verified email. Validate // its shape/length first: this endpoint is unauthenticated, so we avoid // persisting arbitrary or oversized values into the short-lived state store. const rawInvite = url.searchParams.get("invite"); const inviteToken = rawInvite && INVITE_TOKEN_REGEX.test(rawInvite) ? rawInvite : undefined; const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore, { inviteToken, }); return redirect(authUrl); } catch (error) { console.error("OAuth initiation error:", error); return redirect( `${errorRedirectBase}?error=oauth_error&message=${encodeURIComponent("Failed to start OAuth flow. Please try again.")}`, ); } };